//
//  Copyright (c) 2020 Open Whisper Systems. All rights reserved.
//

import Foundation

@objc public class DisplayableText: NSObject {

    private struct Content {
        let attributedText: NSAttributedString
        let naturalAlignment: NSTextAlignment
    }

    private var fullContent: Content
    private var truncatedContent: Content?

    @objc
    public var fullAttributedText: NSAttributedString {
        return fullContent.attributedText
    }

    @objc
    public var fullTextNaturalAlignment: NSTextAlignment {
        return fullContent.naturalAlignment
    }

    @objc
    public var displayAttributedText: NSAttributedString {
        return truncatedContent?.attributedText ?? fullContent.attributedText
    }

    @objc
    public var displayTextNaturalAlignment: NSTextAlignment {
        return truncatedContent?.naturalAlignment ?? fullContent.naturalAlignment
    }

    @objc
    public var isTextTruncated: Bool {
        return truncatedContent != nil
    }

    private static let maxInlineText = 1024 * 8

    @objc
    public var canRenderTruncatedTextInline: Bool {
        return isTextTruncated && fullLengthWithNewLineScalar <= Self.maxInlineText
    }

    @objc
    public let fullLengthWithNewLineScalar: Int

    @objc public let jumbomojiCount: UInt

    @objc
    static let kMaxJumbomojiCount: UInt = 5

    // This value is a bit arbitrary since we don't need to be 100% correct about 
    // rendering "Jumbomoji".  It allows us to place an upper bound on worst-case
    // performacne.
    @objc
    static let kMaxCharactersPerEmojiCount: UInt = 10

    // MARK: Initializers

    private init(fullContent: Content, truncatedContent: Content?) {
        self.fullContent = fullContent
        self.truncatedContent = truncatedContent
        self.jumbomojiCount = DisplayableText.jumbomojiCount(in: fullContent.attributedText.string)
        self.fullLengthWithNewLineScalar = DisplayableText.fullLengthWithNewLineScalar(in: fullContent.attributedText.string)

        super.init()

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(themeDidChange),
            name: .ThemeDidChange,
            object: nil
        )
    }

    @objc private func themeDidChange() {
        // When the theme changes, we must refresh any mention attributes.
        let mutableFullText = NSMutableAttributedString(attributedString: fullAttributedText)
        Mention.refreshAttributes(in: mutableFullText)
        fullContent = Content(
            attributedText: mutableFullText,
            naturalAlignment: fullContent.naturalAlignment
        )

        if let truncatedContent = truncatedContent {
            let mutableTruncatedText = NSMutableAttributedString(attributedString: truncatedContent.attributedText)
            Mention.refreshAttributes(in: mutableTruncatedText)
            self.truncatedContent = Content(
                attributedText: mutableTruncatedText,
                naturalAlignment: truncatedContent.naturalAlignment
            )
        }
    }

    // MARK: Emoji

    // If the string is...
    //
    // * Non-empty
    // * Only contains emoji
    // * Contains <= kMaxJumbomojiCount emoji
    //
    // ...return the number of emoji (to be treated as "Jumbomoji") in the string.
    private class func jumbomojiCount(in string: String) -> UInt {
        if string == "" {
            return 0
        }
        if string.count > Int(kMaxJumbomojiCount * kMaxCharactersPerEmojiCount) {
            return 0
        }
        guard string.containsOnlyEmoji else {
            return 0
        }
        let emojiCount = string.glyphCount
        if UInt(emojiCount) > kMaxJumbomojiCount {
            return 0
        }
        return UInt(emojiCount)
    }

    // For perf we use a static linkDetector. It doesn't change and building DataDetectors is
    // surprisingly expensive. This should be fine, since NSDataDetector is an NSRegularExpression
    // and NSRegularExpressions are thread safe.
    private static let linkDetector: NSDataDetector? = {
        return try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
    }()

    private static let hostRegex: NSRegularExpression? = {
        let pattern = "^(?:https?:\\/\\/)?([^:\\/\\s]+)(.*)?$"
        return try? NSRegularExpression(pattern: pattern)
    }()

    @objc
    public lazy var shouldAllowLinkification: Bool = {
        guard let linkDetector: NSDataDetector = DisplayableText.linkDetector else {
            owsFailDebug("linkDetector was unexpectedly nil")
            return false
        }

        func isValidLink(linkText: String) -> Bool {
            guard let hostRegex = DisplayableText.hostRegex else {
                owsFailDebug("hostRegex was unexpectedly nil")
                return false
            }

            guard let hostText = hostRegex.parseFirstMatch(inText: linkText) else {
                owsFailDebug("hostText was unexpectedly nil")
                return false
            }

            let strippedHost = hostText.replacingOccurrences(of: ".", with: "") as NSString

            if strippedHost.isOnlyASCII {
                return true
            } else if strippedHost.hasAnyASCII {
                // mix of ascii and non-ascii is invalid
                return false
            } else {
                // IDN
                return true
            }
        }

        let rawText = fullAttributedText.string

        for match in linkDetector.matches(in: rawText, options: [], range: NSRange(location: 0, length: rawText.utf16.count)) {
            guard let matchURL: URL = match.url else {
                continue
            }

            // We extract the exact text from the `fullText` rather than use match.url.host
            // because match.url.host actually escapes non-ascii domains into puny-code.
            //
            // But what we really want is to check the text which will ultimately be presented to
            // the user.
            let rawTextOfMatch = (rawText as NSString).substring(with: match.range)
            guard isValidLink(linkText: rawTextOfMatch) else {
                return false
            }
        }
        return true
    }()

    // MARK: Filter Methods

    private static let newLineRegex = try! NSRegularExpression(pattern: "\n", options: [])
    private static let newLineScalar = 16

    private class func fullLengthWithNewLineScalar(in string: String) -> Int {
        let numberOfNewLines = newLineRegex.numberOfMatches(
            in: string,
            options: [],
            range: NSRange(location: 0, length: string.utf16.count)
        )
        return string.utf16.count + numberOfNewLines * newLineScalar
    }

    @objc
    public class var empty: DisplayableText {
        return DisplayableText(
            fullContent: .init(attributedText: .init(string: ""), naturalAlignment: .natural),
            truncatedContent: nil
        )
    }

    @objc
    public class func displayableTextForTests(_ text: String) -> DisplayableText {
        return DisplayableText(
            fullContent: .init(attributedText: .init(string: text), naturalAlignment: text.naturalTextAlignment),
            truncatedContent: nil
        )
    }

    @objc
    public class func displayableText(withMessageBody messageBody: MessageBody, mentionStyle: Mention.Style, transaction: SDSAnyReadTransaction) -> DisplayableText {
        let fullAttributedText = messageBody.attributedBody(
            style: mentionStyle,
            attributes: [:],
            shouldResolveAddress: { _ in true }, // Resolve all mentions in messages.
            transaction: transaction.unwrapGrdbRead
        )
        let fullContent = Content(
            attributedText: fullAttributedText,
            naturalAlignment: fullAttributedText.string.naturalTextAlignment
        )

        // Only show up to N characters of text.
        let kMaxTextDisplayLength = 512
        let kMaxSnippetNewLines = 15
        let truncatedContent: Content?

        if fullAttributedText.string.count > kMaxTextDisplayLength {
            var snippetLength = kMaxTextDisplayLength

            // Message bubbles by default should be short. We don't ever
            // want to show more than X new lines in the truncated text.po
            let newLineMatches = newLineRegex.matches(
                in: fullAttributedText.string,
                options: [],
                range: NSRange(location: 0, length: kMaxTextDisplayLength)
            )
            if newLineMatches.count > kMaxSnippetNewLines {
                snippetLength = newLineMatches[kMaxSnippetNewLines - 1].range.location
            }

            var mentionRange = NSRange()
            let possibleOverlappingMention = fullAttributedText.attribute(
                .mention,
                at: snippetLength,
                longestEffectiveRange: &mentionRange,
                in: NSRange(location: 0, length: fullAttributedText.length)
            )

            // There's a mention overlapping our normal truncate point, we want to truncate sooner
            // so we don't "split" the mention.
            if possibleOverlappingMention != nil && mentionRange.location < snippetLength {
                snippetLength = mentionRange.location
            }

            // Trim whitespace before _AND_ after slicing the snipper from the string.
            let truncatedAttributedText = fullAttributedText
                .attributedSubstring(from: NSRange(location: 0, length: snippetLength))
                .ows_stripped()
                .stringByAppendingString("…")

            truncatedContent = Content(
                attributedText: truncatedAttributedText,
                naturalAlignment: truncatedAttributedText.string.naturalTextAlignment
            )
        } else {
            truncatedContent = nil
        }

        return DisplayableText(fullContent: fullContent, truncatedContent: truncatedContent)
    }
}
